Skip to content

core: expose permission profile to shell tools#29941

Merged
bolinfest merged 1 commit into
mainfrom
pr29941
Jun 25, 2026
Merged

core: expose permission profile to shell tools#29941
bolinfest merged 1 commit into
mainfrom
pr29941

Conversation

@bolinfest

@bolinfest bolinfest commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

tl;dr

Inject a CODEX_PERMISSION_PROFILE environment variable with the name of the current permission profile when invoking a shell tool.

Why

Shell tool owners may need to launch nested commands under the same named permission profile, including through codex sandbox -P PROFILE --include-managed-config. Until now, child processes could observe sandbox and network metadata but could not identify the active named permission profile.

The --include-managed-config flag is essential when a helper reconstructs the sandbox from a profile name: it ensures the nested sandbox also loads managed enterprise requirements. Without it, using the inherited profile could unintentionally create a sandbox that does not enforce the organization's managed restrictions.

The new environment value is intentionally informational and must not be treated as trusted input. Any process in the ancestry can overwrite an environment variable, so a consumer that passes this value to codex sandbox -P must first validate it against the profiles that helper is authorized to use.

Example Use Case

Suppose an organization provides a trusted remote-bash wrapper that lets Codex run a command on an approved build host. The local shell command uses the named :workspace permission profile:

default_permissions = ":workspace"

The command exposed to the model is a small zsh wrapper. It deliberately delegates with exec, preserving the original arguments and process environment:

#!/usr/bin/env zsh
exec /opt/codex-tools/remote_bash.py "$@"

The model invokes the public wrapper, not its Python implementation:

/opt/codex-tools/remote-bash \
  --host builder.example.com \
  -- printf '%s' 'hello world'

Only the inner implementation is authorized to escape the local sandbox:

prefix_rule(
    pattern=["/opt/codex-tools/remote_bash.py"],
    decision="allow",
)

With zsh-fork, execution begins with remote-bash inside the :workspace sandbox. When the wrapper calls exec, the exact prefix rule matches remote_bash.py, so that inner script is restarted unsandboxed. The escalated process inherits:

CODEX_PERMISSION_PROFILE=:workspace

Inheritance does not make the value trustworthy. remote_bash.py independently allowlists both the remote host and the permission profile before using either value. In particular, a forged value such as :danger-full-access is rejected before it can reach codex sandbox -P:

import argparse
import os
import shlex
import sys

ALLOWED_HOSTS = {"builder.example.com"}
ALLOWED_PROFILES = {":workspace"}

parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
separator = sys.argv.index("--")
args = parser.parse_args(sys.argv[1:separator])
command = sys.argv[separator + 1:]

if args.host not in ALLOWED_HOSTS:
    parser.error("host is not allowlisted")
if not command:
    parser.error("the remote command must not be empty")

profile = os.environ.get("CODEX_PERMISSION_PROFILE")
if not profile:
    raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty")
if profile not in ALLOWED_PROFILES:
    raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted")

remote_command = shlex.join(command)
sandbox_command = shlex.join([
    "codex", "sandbox", "-P", profile,
    "--include-managed-config", "--",
    "bash", "-lc", remote_command,
])
print(shlex.join(["ssh", args.host, sandbox_command]))

This builds each command layer as an argument vector and uses shlex.join() at the boundary, rather than interpolating untrusted shell text. After validation and parsing, the nested command has this structure:

ssh argv:
  ["ssh", "builder.example.com", SANDBOX_COMMAND]

SANDBOX_COMMAND argv:
  ["codex", "sandbox", "-P", ":workspace",
   "--include-managed-config", "--",
   "bash", "-lc", "printf %s 'hello world'"]

bash -lc payload argv:
  ["printf", "%s", "hello world"]

A production implementation could execute that SSH command. The integration fixture prints it and parses the result back into arguments, verifying the complete flow:

model invokes outer wrapper
  -> zsh-fork starts wrapper under :workspace
  -> wrapper execs allowlisted Python script
  -> prefix rule restarts Python script unsandboxed
  -> Python script inherits CODEX_PERMISSION_PROFILE=:workspace
  -> Python script verifies :workspace is allowlisted
  -> remote command runs codex sandbox -P :workspace
     with --include-managed-config
  -> nested sandbox honors managed enterprise requirements

This gives the trusted helper access to resources outside the local sandbox—such as SSH credentials—while ensuring that it can select only an explicitly authorized profile and that work on the remote host remains subject to the organization's managed requirements.

What changed

  • Inject CODEX_PERMISSION_PROFILE after shell environment policy evaluation so the active profile wins over inherited or configured stale values.
  • Apply the variable to both shell_command and unified exec_command, including local, zsh-fork, and remote exec-server paths.
  • Remove stale values when the session has no active named profile.
  • Preserve the current profile value when loading a shell snapshot so a parent snapshot cannot restore an older profile.

Testing

  • Added classic-shell integration coverage proving an exact prefix rule can run a require_escalated script outside the :workspace sandbox while preserving CODEX_PERMISSION_PROFILE=:workspace.
  • Added zsh-fork integration coverage in which the model invokes an outer zsh wrapper, an inner allowlisted remote_bash.py runs unsandboxed, and its printed SSH command reconstructs the inherited :workspace sandbox with --include-managed-config while preserving every argument after --.
  • The example helper treats CODEX_PERMISSION_PROFILE as untrusted and validates it against ALLOWED_PROFILES before constructing the nested command.
  • Assert that the reconstructed sandbox command includes --include-managed-config so nested use of the inherited profile cannot bypass managed enterprise requirements.
  • Added coverage for overriding and removing stale profile values.
  • Verified shell_command receives the selected active profile.
  • Added shell snapshot coverage using printenv CODEX_PERMISSION_PROFILE.

@bolinfest bolinfest requested a review from a team as a code owner June 25, 2026 01:24

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8600629232

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/core/src/tools/runtimes/mod.rs
Comment thread codex-rs/core/src/exec_env.rs
Comment thread codex-rs/core/src/unified_exec/process_manager.rs
Comment thread codex-rs/core/src/unified_exec/process_manager.rs
@bolinfest bolinfest force-pushed the pr29941 branch 6 times, most recently from 4d18c41 to 7cbf210 Compare June 25, 2026 17:31
@bolinfest bolinfest requested review from jif-oai and viyatb-oai June 25, 2026 17:37

@jif-oai jif-oai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly testing so I pre-approve

Comment thread codex-rs/core/tests/suite/approvals.rs Outdated

shell_command = shlex.join(args.command)
sandbox_command = shlex.join(
["codex", "sandbox", "-P", profile_name, "--", "bash", "-lc", shell_command]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn’t trusted input here... The model can prefix the wrapper invocation with CODEX_PERMISSION_PROFILE=:danger-full-access. zsh-fork forwards that environment into the unsandboxed helper, and this code then selects the attacker-chosen remote profile

Can we inject the profile at the trusted escalation boundary, or keep this variable strictly informational and drop the enforcement example?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, everything you say is true: as noted in the PR body:

The new value is intentionally informational: child processes can overwrite environment variables, so consumers must decide whether their process-tree context is trusted before using it.

As for this:

Can we inject the profile at the trusted escalation boundary, or keep this variable strictly informational and drop the enforcement example?

Yes, I would like to introduce a more authoritative mechanism, but I think it's going to take some work to figure out how to do that. I suspect we will have to use OS-specific APIs to achieve this, but for now, this will at least give users something to play with.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jif-oai Though to your point, I updated the example to specify a ALLOWED_PROFILES just like it has an ALLOWED_HOSTS to illustrate that CODEX_PERMISSION_PROFILE should be checked rather than silently passed through.

Comment thread codex-rs/core/tests/suite/approvals.rs Outdated

shell_command = shlex.join(args.command)
sandbox_command = shlex.join(
["codex", "sandbox", "-P", profile_name, "--", "bash", "-lc", shell_command]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And doesn't the -P ignores the managed requirements?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! Though we have a --include-managed-config option to exercise cloud requirements, so I'll update the PR body and code to include this.

@bolinfest bolinfest force-pushed the pr29941 branch 2 times, most recently from 3e056d3 to cf7fb2d Compare June 25, 2026 18:20
@bolinfest bolinfest enabled auto-merge (squash) June 25, 2026 18:55
@bolinfest bolinfest merged commit c65cfea into main Jun 25, 2026
47 of 62 checks passed
@bolinfest bolinfest deleted the pr29941 branch June 25, 2026 19:00
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants